WireGuard Protocol
Whitepaper
Protocol Overview
Primitives
Connection-less Protocol
セキュアプロトコルは保持すべきステートを要求する
そのため初回に、「データ転送用の対象鍵」を確立する非常にシンプルなハンドシェイクが存在する
このハンドシェイクは、完全な前方秘匿 (forward secrecy) のための鍵ローテーションを提供するために数分おきに発生する
これは以前のパケットの内容ではなく時間に基づいて行なわれる
パケットロスをうまく扱うようにデザインされているため
ハンドシェイクが古くなった (out of date) ときにそれを自動的に検出することにより、直近の鍵とハンドシェイクが最新であることを保証し、再ネゴシエーションを必要に応じて行う賢いパルス機構がある
ホストごとに分離されたパケットキのキューを利用することで、ハンドシェイク中のパケットロスを最小化し、全てのクライアントに対して一貫したパフォーマンスを提供する
これはつまり、デバイスを起動すると、他のすべてが自動的に処理される。
再接続、切断、再初期化、その他の似たようなものを要求する必要はない
以下のタイマーが機能する:
もしレスポンスを受信しなかった際、ハンドシェイクのイニシエーションが REKEY_TIMEOUT + jitter ミリ秒後にリトライされる
jitterはランダムな値であり、0から333 msの間におさまる
特定のピアからパケットを受信したが、KEEPALIVE ミリ秒以内に特定のピアにパケットを返送しなかった場合、空のパケットを送信する
特定のピアにパケットを送信したが、そのピアからのパケットをKEEPALIVE + REKEY_TIMEOUTミリ秒以内に受信しなかった場合、新しいハンドシェイクを開始する
もし交換された新しい鍵が無い場合、REJETCT_AFTER_TIME * 3ミリ秒後にすべてのエフェメラルなプライベート鍵と対象セッション鍵 (Symmetric session key) はゼロ化 (zeroed out) される
パケットを送信した後、もしその送信者がハンドシェイクのオリジナルの開始者 (original initiator) かつ現在のセッション鍵が作成されてからREKEY_AFTER_TIMEミリ秒経過していた場合、新しいハンドシェイクを開始する。
もしその送信者がハンドシェイクのオリジナルの応答者だった場合、オリジナルの開始者がするように、REKEY_AFTER_TIME ミリ秒が経過していたとしても、新しいハンドシェイクを再度開始 (reinitiate) しない。
パケットを受信した後、もしその受信者がハンドシェイクのオリジナルの開始者かつ現在のセッション鍵が作成されてからREKEY_AFTER_TIME - KEEPALIVE_TIMEOUT - REKEY_TIMEOUTミリ秒が経過していた場合、新しいハンドシェイクを開始する
ハンドシェイクは、REKEY_TIMEOUTミリ秒ごとに1回だけ開始され、この厳密なレート制限が適用される
もしセッションカウンターがREJECT_AFTER_MESSAGESよりも大きいか、その鍵 (TODO: セッション鍵?) が REJECT_AFTER_TIMEミリ秒より古い場合、パケットはドロップされる
新しいハンドシェイクの開始の試行からREKEY_ATTEMPT_TIMEが経過した後、リトライを断念し終了し、存在しているすべてのパケットを送信キューからクリアする
もしパケットが送信のために明示的にキューに入れられた場合、そのタイマーはリセットされる
REKEY_TIMEOUTを、exponential back-offを使用するように調整することが今後の作業として存在する
initiatorからresponderへ、次にresponderからinitiaterへ戻すメッセージを使用したハンドシェイクが完了した後、initiatorは暗号化されたセッションパケットを送信できるが、responderはそれができない
そのresponderは、新しいセッションを使用するにあたり、暗号化されたセッションパケットをinitiatorから受信するまで待たなければならない
key confirmationを提供するために
したがって、responderが新しい確立されたセッションを使う最初のパケットを受信するまで、後で送信するパケットをキューイングするか、もしくは以前のセッション (存在していて、validでなければならない) を使うかする必要がある
つまり、responderからのレスポンスをinitiaterが受信したあと、もしただちに送信すべきパケットデータがキューに無い場合、空のパケットをconfirmationとして送信すべきである。
Key Exchange and Data Packets
すべてのパケットはUDPを介して送信される
鍵交換には以下のような良い特徴がある
鍵の侵害によるなりすまし (key-compromise impersonation) の防止
リプレイ攻撃の防止
識別子 (Identity) の秘匿
もし、対象鍵暗号の追加レイヤが要求される場合 (例えば、量子化後耐性など)、WireGuardは公開鍵暗号に混合される (mixed) 事前共有鍵 (PSK) もオプショナルにサポートする
事前共有鍵モードを使用しない時、以下で使用される事前共有鍵の値は全てがゼロの32ビット文字列と仮定される。
以下のパケットの説明については、これらのfunctionsを参照すること
DH(private key, public key: 秘密鍵と公開鍵のCurve25519 point multiplication。32バイトの出力を返却する DH_GENERATE(): ランダムなCurve22519の秘密鍵を生成する。32バイトの出力を返却する
RAND(len): lenぶんだけランダムなバイト列を返却する
DH_PUBKEY(private key): 秘密鍵からCurve25519の公開鍵を計算する。32バイトの出力を返却する
AEAD(key, counter, plain text, auth text): ChaCha20Poly1305 AEAD (RFC7539で策定されている) とそのnonceは、32ビットのゼロの後に続く64 bitリトルエンディアン値のカウンタによって構成される AEAD_LEN(plain len): plain len + 16
HMAC(key ,input): HMAC-Blake2s(key, input, 32). 32バイト出力を返却
MAC(key, input): Keyed-Blake2s(key, input, 16). 16バイト出力を返却
HASH(input): Blake2s(input, 32). 32バイト出力を返却
TAI64N(): 12バイトで表現される現在時刻のTAI64Nタイムスタンプ CONSTRUCTION: Noise_IKpsk2_25519_ChaChaPoly_BLAKE2sのUTF8値。37バイト
IDENTIFIER: WireGuard v1 zx2c4 Jason@zx2c4.comのUTF8値。34バイト
LABEL_MAC1: mac1----のUTF8値。8バイト
LABEL_COOKIE: cookie--のUTF8値。8バイト
First Message: Initiator to Responder
Initiatorは以下のメッセージを送信する
code:rust
msg = handshake_initiation {
u8 message_type
u32 sender_index
u8 unencrypted_ephemeral32 }
以下の疑似コードによって表現される
code:python
initiator.chaining_key = HASH(CONSTRUCTION)
initiator.hash = HASH(HASH(initiator.chaining_key || IDENTIFIER) || responder.static_public)
initiator.ephemeral_private = DH_GENERATE()
msg.message_type = 1
msg.reserved_zero = { 0, 0, 0 }
msg.sender_index = little_endian(initiator.sender_index)
msg.unencrypted_ephemeral = DH_PUBKEY(initiator.ephemeral_private)
initiator.hash = HASH(initiator.hash || msg.unencrypted_ephemeral)
temp = HMAC(initiator.chaining_key, msg.unencrypted_ephemeral)
initiator.chaining_key = HMAC(temp, 0x1)
temp = HMAC(initiator.chaining_key, DH(initiator.ephemeral_private, responder.static_public))
initiator.chaining_key = HMAC(temp, 0x1)
key = HMAC(temp, initiator.chaining_key || 0x2)
msg.encrypted_static = AEAD(key, 0, initiator.static_public, initiator.hash)
initiator.hash = HASH(initiator.hash || msg.encrypted_static)
temp = HMAC(initiator.chaining_key, DH(initiator.static_private, responder.static_public))
initiator.chaining_key = HMAC(temp, 0x1)
key = HMAC(temp, initiator.chaining_key || 0x2)
msg.encrypted_timestamp = AEAD(key, 0, TAI64N(), initiator.hash)
initiator.hash = HASH(initiator.hash || msg.encrypted_timestamp)
if (initiator.last_received_cookie is empty or expired)
else
responderがこのメッセージを受信したとき、responderは復号化し、上記のすべての処理を逆に実行するため、ステートは同一となる
Second Message: Responder to Initiator
First messageを処理し、同様のオペレーションを適用して同一のステートに到達したのち、responderは以下のメッセージを送信する
code:rust
msg = handshake_response {
u8 message_type
u32 sender_index
u32 receiver_index
u8 unencrypted_ephemeral32 }
以下の疑似コードによって表現される
code:python
responder.ephemeral_private = DH_GENERATE()
msg.message_type = 2
msg.reserved_zero = { 0, 0, 0 }
msg.sender_index = little_endian(responder.sender_index)
msg.receiver_index = little_endian(initiator.sender_index)
msg.unencrypted_ephemeral = DH_PUBKEY(responder.ephemeral_private)
responder.hash = HASH(responder.hash || msg.unencrypted_ephemeral)
temp = HMAC(responder.chaining_key, msg.unencrypted_ephemeral)
responder.chaining_key = HMAC(temp, 0x1)
temp = HMAC(responder.chaining_key, DH(responder.ephemeral_private, initiator.ephemeral_public))
responder.chaining_key = HMAC(temp, 0x1)
temp = HMAC(responder.chaining_key, DH(responder.ephemeral_private, initiator.static_public))
responder.chaining_key = HMAC(temp, 0x1)
temp = HMAC(responder.chaining_key, preshared_key)
responder.chaining_key = HMAC(temp, 0x1)
temp2 = HMAC(temp, responder.chaining_key || 0x2)
key = HMAC(temp, temp2 || 0x3)
responder.hash = HASH(responder.hash || temp2)
msg.encrypted_nothing = AEAD(key, 0, empty, responder.hash) responder.hash = HASH(responder.hash || msg.encrypted_nothing)
if (responder.last_received_cookie is empty or expired)
else
initiatorがこのメッセージを受信したとき、initiatorは復号化し、上記の全ての処理を逆に実行するので、ステートは同一となる
Data Keys Derivation
上記の2つのメッセージ交換 (first message & second message) の後、initiatorとresponderによってデータ送受信のための鍵の計算が行なわれる
疑似コード
code:python
temp1 = HMAC(initiator.chaining_key, empty) temp2 = HMAC(temp1, 0x1)
temp3 = HMAC(temp1, temp2 || 0x2)
initiator.sending_key = temp2
initiator.receiving_key = temp3
initiator.sending_key_counter = 0
initiator.receiving_key_counter = 0
temp1 = HMAC(responder.chaining_key, empty) temp2 = HMAC(temp1, 0x1)
temp3 = HMAC(temp1, temp2 || 0x2)
responder.receiving_key = temp2
responder.sending_key = temp3
responder.receiving_key_counter = 0
responder.sending_key_counter = 0
このとき、すべてのそれ以前のチェインされたキー群、一時的な (ephemeral) キー群そしてハッシュ群はゼロ化される
Subsequent Message: Exchange of Data Packets
initiatorとresponderはカプセル化されたパケットデータの共有のために以下のパケットを交換する。
code:rust
msg = packet_data {
u8 message_type
u32 receiver_index
u64 counter
u8 encrypted_encapsulated_packet[]
}
このフィールドは以下の疑似コードによって表現される
code:python
msg.message_type = 4
msg.reserved_zero = { 0, 0, 0 }
msg.receiver_index = little_endian(responder.sender_index)
encapsulated_packet = encapsulated_packet || zero padding in order to make the length a multiple of 16
counter = initiator.sending_key_counter++
msg.counter = little_endian(counter)
msg.encrypted_encapsulated_packet = AEAD(initiator.sending_key, counter, encapsulated_packet, empty) responderはresponder.receiving_keyを使ってメッセージを読み取る
DoS Mitigation
認証されていない可能性のあるメッセージに対してサーバ上にステートを割り当てる必要がないため、最初に送信されるハンドシェイクメッセージで認証が必要となる
まだ認証されていないパケットに応答して任意の状態を作成できるようにすることで、ハンドシェイクはDoS脆弱性を回避する
ただし、first packetに認証が持ち込まれるという問題が発生する
これは常にリプレイ攻撃に対してオープンであることを意味する
攻撃者は、初回ハンドシェイクメッセージをリプレイすることで、サーバーを騙して一時的な鍵を再生成させ、正当なクライアントの接続を切断することが可能である
メッセージのセキュリティには影響しない
そのため、TAI64Nのタイムスタンプを最初のメッセージに含める。
サーバーは、クラアントごとの最も大きいタイムスタンプの記録を保持し、またその最大のタイムスタンプと同じかそれより小さいタイムスタンプを持つパケットを破棄する
もしサーバーが再起動し、そのステートを喪失した場合でも、それは問題とならない
以前の最初のパケットはリプレイできるが、サーバーは再起動したてなので、進行中のセッションを中断することはできない
クライアントが再起動後のサーバーに再接続した場合、クライアントとサーバーはそれらの間で大きい方のタイムスタンプを採用し利用し、古いタイムスタンプを無効なものとする
このタイムスタンプは攻撃者が現在のサーバー・クライアント間のセッションを破壊できないことを保証する
加えて、DH()機能の計算はCPUを激しく使う。
CPU消耗攻撃 (めちゃめちゃ計算させるDoSみたいな攻撃のこと?) を回避するために、もしサーバーに負荷がかかっている場合、ハンドシェイクメッセージを処理せず、その代わりにcookie replyパケットによって応答することを選択する可能性がある。
サーバーが、有効なパケットを受信しない限りサイレントな状態を維持するには、負荷がかかっている間、そべてのメッセージに対し受信者の公開鍵とオプションでMAC鍵としてのPSKを組み合わせたMACが必要となる。
サーバーに負荷がかかっているとき、サーバーは「MAC鍵としてcookieを利用したメッセージ」の前のバイト列のsecond MACを追加として持つパケットのみを受け入れる。
msg.mac1とmsg.mac2を上記で見たようなハンドシェイクメッセージとして計算する
クッキーは2分が経過すると揮発する
クッキーは、「Mac Keyとして、2分ごとに変更されるサーバーシークレット」を使用する送信者のIPアドレスのMACである
これによりIPのオーナーシップの証明が可能になり、正しいレートリミットが可能となる
これらのMACを適切に計算し、受信したメッセージ中のMACと比較した後、サーバーは不正なmsg.mac1を持つメッセージを破棄しなければならず、また負荷がかかっている時は不正なmsg.mac2を持つメッセージを破棄しなくてはならない
Cookie Reply Packet
上記のように、正しいmsg.ma1を持つメッセージが受信されたが、msg.mac2が全てゼロか不正なときかつサーバーに負荷がかかっている時、サーバーは以下のようなcookie reply packetを送信することがある:
code:rust
msg = packet_cookie_reply {
u8 message_type
u32 receiver_index
}
msg.message_type = 3
msg.reserved_zero = { 0, 0, 0 }
msg.receiver_index = little_endian(initiator.sender_index)
msg.nonce = RAND(24)
cookie = MAC(responder.changing_secret_every_two_minutes, initiator.ip_address)
msg.encrypted_cookie = XAEAD(HASH(LABEL_COOKIE || responder.static_public), msg.nonce, cookie, last_received_msg.mac1)
Nonce Reuse & Replay Attacks
Noncesは決して再利用されない
64bitカウンターが利用されており、巻き戻しは不可能である
しかし、UDPはメッセージを順不同で配送することがある。 そのため、スライディングウインドウを使用する
このウィンドウは受信したカウンターの最大値と、認証タグの検証後にチェックされた約2000個以前の値のウィンドウを保持して追跡する
これにより、nonceが再利用されないことを保証し、UDPは順不同でパケットを送信するこでパフォーマンスを維持しつつ、リプレイ攻撃を回避することができる
DiffServ Considerations
IPパケット中のDiffServe bitsは、一般的に2つの部分に分割される
DSCP値経由のquality of service (QoS) と、明示的な輻輳通知 (ECN) に使用されるビットを含む
全てのハンドシェイクパケットは 0x88 (AF41) のDSCP値を持ち、これらのパケットはトンネルの制御機能に必要不可欠であり、ECNが 00 に設定されるため、ドロップされる可能性が最も低くなる。
内部パケットのDSCP値は外部パケットにコピーされることはなく、暗号化された内部パケット内のデータに関する情報は漏洩しないため、全てのデータ転送パケットはDSCP値が0となる
しかし、RFC6040で説明されるロジックに従い、内部パケットと内部パケット間ではECNビットがコピーされる。